'use strict'

import Container from './container';
import Document from './document';
import MapGenerator from './map-generator';
import parse from './parse';
import Result from './result';
import Root from './root';
import stringify from './stringify';
import { isClean, my } from './symbols';
import warnOnce from './warn-once';

const TYPE_TO_CLASS_NAME = {
    atrule: 'AtRule',
    comment: 'Comment',
    decl: 'Declaration',
    document: 'Document',
    root: 'Root',
    rule: 'Rule'
}

const PLUGIN_PROPS = {
    AtRule: true,
    AtRuleExit: true,
    Comment: true,
    CommentExit: true,
    Declaration: true,
    DeclarationExit: true,
    Document: true,
    DocumentExit: true,
    Once: true,
    OnceExit: true,
    postcssPlugin: true,
    prepare: true,
    Root: true,
    RootExit: true,
    Rule: true,
    RuleExit: true
}

const NOT_VISITORS = {
    Once: true,
    postcssPlugin: true,
    prepare: true
}

const CHILDREN = 0

function isPromise(obj) {
    return typeof obj === 'object' && typeof obj.then === 'function'
}

function getEvents(node) {
    let key = false
    let type = TYPE_TO_CLASS_NAME[node.type]
    if (node.type === 'decl') {
        key = node.prop.toLowerCase()
    } else if (node.type === 'atrule') {
        key = node.name.toLowerCase()
    }

    if (key && node.append) {
        return [
            type,
            type + '-' + key,
            CHILDREN,
            type + 'Exit',
            type + 'Exit-' + key
        ]
    } else if (key) {
        return [type, type + '-' + key, type + 'Exit', type + 'Exit-' + key]
    } else if (node.append) {
        return [type, CHILDREN, type + 'Exit']
    } else {
        return [type, type + 'Exit']
    }
}

function toStack(node) {
    let events
    if (node.type === 'document') {
        events = ['Document', CHILDREN, 'DocumentExit']
    } else if (node.type === 'root') {
        events = ['Root', CHILDREN, 'RootExit']
    } else {
        events = getEvents(node)
    }

    return {
        eventIndex: 0,
        events,
        iterator: 0,
        node,
        visitorIndex: 0,
        visitors: []
    }
}

function cleanMarks(node) {
    node[isClean] = false
    if (node.nodes) {
        node.nodes.forEach(i => cleanMarks(i))
    }
    return node
}

let postcss = {}

class LazyResult {
    constructor(processor, css, opts) {
        this.stringified = false
        this.processed = false

        let root
        if (
            typeof css === 'object' &&
                css !== null &&
                (css.type === 'root' || css.type === 'document')
        ) {
            root = cleanMarks(css)
        } else if (css instanceof LazyResult || css instanceof Result) {
            root = cleanMarks(css.root)
            if (css.map) {
                if (typeof opts.map === 'undefined') {
                    opts.map = {}
                }
                if (!opts.map.inline) {
                    opts.map.inline = false
                }
                opts.map.prev = css.map
            }
        } else {
            let parser = parse
            if (opts.syntax) {
                parser = opts.syntax.parse
            }
            if (opts.parser) {
                parser = opts.parser
            }
            if (parser.parse) {
                parser = parser.parse
            }

            try {
                root = parser(css, opts)
            } catch (error) {
                this.processed = true
                this.error = error
            }

            if (root && !root[my]) {
                /* c8 ignore next 2 */
                Container.rebuild(root)
            }
        }

        this.result = new Result(processor, root, opts)
        this.helpers = { ...postcss, postcss, result: this.result }
        this.plugins = this.processor.plugins.map(plugin => {
            if (typeof plugin === 'object' && plugin.prepare) {
                return { ...plugin, ...plugin.prepare(this.result) }
            } else {
                return plugin
            }
        })
    }

    async() {
        if (this.error) {
            return Promise.reject(this.error)
        }
        if (this.processed) {
            return Promise.resolve(this.result)
        }
        if (!this.processing) {
            this.processing = this.runAsync()
        }
        return this.processing
    }

    catch(onRejected) {
        return this.async().catch(onRejected)
    }

    finally(onFinally) {
        return this.async().then(onFinally, onFinally)
    }

    getAsyncError() {
        throw new Error('Use process(css).then(cb) to work with async plugins')
    }

    handleError(error, node) {
        let plugin = this.result.lastPlugin
        try {
            if (node) {
                node.addToError(error)
            }
            this.error = error
            if (error.name === 'CssSyntaxError' && !error.plugin) {
                error.plugin = plugin.postcssPlugin
                error.setMessage()
            }
        } catch (err) {
            /* c8 ignore next 3 */
            // eslint-disable-next-line no-console
            if (console && console.error) {
                console.error(err)
            }
        }
        return error
    }

    prepareVisitors() {
        this.listeners = {}
        let add = (plugin, type, cb) => {
            if (!this.listeners[type]) {
                this.listeners[type] = []
            }
            this.listeners[type].push([plugin, cb])
        }
        for (let plugin of this.plugins) {
            if (typeof plugin === 'object') {
                for (let event in plugin) {
                    if (!PLUGIN_PROPS[event] && /^[A-Z]/.test(event)) {
                        throw new Error(
                            `Unknown event ${event} in ${plugin.postcssPlugin}. ` +
                                `Try to update PostCSS (${this.processor.version} now).`
                        )
                    }
                    if (!NOT_VISITORS[event]) {
                        if (typeof plugin[event] === 'object') {
                            for (let filter in plugin[event]) {
                                if (filter === '*') {
                                    add(plugin, event, plugin[event][filter])
                                } else {
                                    add(
                                        plugin,
                                        event + '-' + filter.toLowerCase(),
                                        plugin[event][filter]
                                    )
                                }
                            }
                        } else if (typeof plugin[event] === 'function') {
                            add(plugin, event, plugin[event])
                        }
                    }
                }
            }
        }
        this.hasListener = Object.keys(this.listeners).length > 0
    }

    async runAsync() {
        this.plugin = 0
        for (let i = 0; i < this.plugins.length; i++) {
            let plugin = this.plugins[i]
            let promise = this.runOnRoot(plugin)
            if (isPromise(promise)) {
                try {
                    await promise
                } catch (error) {
                    throw this.handleError(error)
                }
            }
        }

        this.prepareVisitors()
        if (this.hasListener) {
            let root = this.result.root
            while (!root[isClean]) {
                root[isClean] = true
                let stack = [toStack(root)]
                while (stack.length > 0) {
                    let promise = this.visitTick(stack)
                    if (isPromise(promise)) {
                        try {
                            await promise
                        } catch (e) {
                            let node = stack[stack.length - 1].node
                            throw this.handleError(e, node)
                        }
                    }
                }
            }

            if (this.listeners.OnceExit) {
                for (let [plugin, visitor] of this.listeners.OnceExit) {
                    this.result.lastPlugin = plugin
                    try {
                        if (root.type === 'document') {
                            let roots = root.nodes.map(subRoot =>
                            visitor(subRoot, this.helpers)
                            )

                            await Promise.all(roots)
                        } else {
                            await visitor(root, this.helpers)
                        }
                    } catch (e) {
                        throw this.handleError(e)
                    }
                }
            }
        }

        this.processed = true
        return this.stringify()
    }

    runOnRoot(plugin) {
        this.result.lastPlugin = plugin
        try {
            if (typeof plugin === 'object' && plugin.Once) {
                if (this.result.root.type === 'document') {
                    let roots = this.result.root.nodes.map(root =>
                    plugin.Once(root, this.helpers)
                    )

                    if (isPromise(roots[0])) {
                        return Promise.all(roots)
                    }

                    return roots
                }

                return plugin.Once(this.result.root, this.helpers)
            } else if (typeof plugin === 'function') {
                return plugin(this.result.root, this.result)
            }
        } catch (error) {
            throw this.handleError(error)
        }
    }

    stringify() {
        if (this.error) {
            throw this.error
        }
        if (this.stringified) {
            return this.result
        }
        this.stringified = true

        this.sync()

        let opts = this.result.opts
        let str = stringify
        if (opts.syntax) {
            str = opts.syntax.stringify
        }
        if (opts.stringifier) {
            str = opts.stringifier
        }
        if (str.stringify) {
            str = str.stringify
        }

        let map = new MapGenerator(str, this.result.root, this.result.opts)
        let data = map.generate()
        this.result.css = data[0]
        this.result.map = data[1]

        return this.result
    }

    sync() {
        if (this.error) {
            throw this.error
        }
        if (this.processed) {
            return this.result
        }
        this.processed = true

        if (this.processing) {
            throw this.getAsyncError()
        }

        for (let plugin of this.plugins) {
            let promise = this.runOnRoot(plugin)
            if (isPromise(promise)) {
                throw this.getAsyncError()
            }
        }

        this.prepareVisitors()
        if (this.hasListener) {
            let root = this.result.root
            while (!root[isClean]) {
                root[isClean] = true
                this.walkSync(root)
            }
            if (this.listeners.OnceExit) {
                if (root.type === 'document') {
                    for (let subRoot of root.nodes) {
                        this.visitSync(this.listeners.OnceExit, subRoot)
                    }
                } else {
                    this.visitSync(this.listeners.OnceExit, root)
                }
            }
        }

        return this.result
    }

    then(onFulfilled, onRejected) {
        return this.async().then(onFulfilled, onRejected)
    }

    toString() {
        return this.css
    }

    visitSync(visitors, node) {
        for (let [plugin, visitor] of visitors) {
            this.result.lastPlugin = plugin
            let promise
            try {
                promise = visitor(node, this.helpers)
            } catch (e) {
                throw this.handleError(e, node.proxyOf)
            }
            if (node.type !== 'root' && node.type !== 'document' && !node.parent) {
                return true
            }
            if (isPromise(promise)) {
                throw this.getAsyncError()
            }
        }
    }

    visitTick(stack) {
        let visit = stack[stack.length - 1]
        let { node, visitors } = visit

        if (node.type !== 'root' && node.type !== 'document' && !node.parent) {
            stack.pop()
            return
        }

        if (visitors.length > 0 && visit.visitorIndex < visitors.length) {
            let [plugin, visitor] = visitors[visit.visitorIndex]
            visit.visitorIndex += 1
            if (visit.visitorIndex === visitors.length) {
                visit.visitors = []
                visit.visitorIndex = 0
            }
            this.result.lastPlugin = plugin
            try {
                return visitor(node.toProxy(), this.helpers)
            } catch (e) {
                throw this.handleError(e, node)
            }
        }

        if (visit.iterator !== 0) {
            let iterator = visit.iterator
            let child
            while ((child = node.nodes[node.indexes[iterator]])) {
                node.indexes[iterator] += 1
                if (!child[isClean]) {
                    child[isClean] = true
                    stack.push(toStack(child))
                    return
                }
            }
            visit.iterator = 0
            delete node.indexes[iterator]
        }

        let events = visit.events
        while (visit.eventIndex < events.length) {
            let event = events[visit.eventIndex]
            visit.eventIndex += 1
            if (event === CHILDREN) {
                if (node.nodes && node.nodes.length) {
                    node[isClean] = true
                    visit.iterator = node.getIterator()
                }
                return
            } else if (this.listeners[event]) {
                visit.visitors = this.listeners[event]
                return
            }
        }
        stack.pop()
    }

    walkSync(node) {
        node[isClean] = true
        let events = getEvents(node)
        for (let event of events) {
            if (event === CHILDREN) {
                if (node.nodes) {
                    node.each(child => {
                        if (!child[isClean]) {
                            this.walkSync(child)
                        }
                    })
                }
            } else {
                let visitors = this.listeners[event]
                if (visitors) {
                    if (this.visitSync(visitors, node.toProxy())) {
                        return
                    }
                }
            }
        }
    }

    warnings() {
        return this.sync().warnings()
    }

    get content() {
        return this.stringify().content
    }

    get css() {
        return this.stringify().css
    }

    get map() {
        return this.stringify().map
    }

    get messages() {
        return this.sync().messages
    }

    get opts() {
        return this.result.opts
    }

    get processor() {
        return this.result.processor
    }

    get root() {
        return this.sync().root
    }

    get [Symbol.toStringTag]() {
        return 'LazyResult'
    }
}

LazyResult.registerPostcss = dependant => {
    postcss = dependant
}

export { LazyResult }

export default LazyResult

Root.registerLazyResult(LazyResult)
Document.registerLazyResult(LazyResult)
